package pnt.client;


import pnt.*;
import java.io.*;
import java.net.*;

//  PNTClient.java
import java.util.Collections;
import java.util.Enumeration;
import java.util.logging.Level;
import java.util.logging.Logger;
//
//  Copyright 2007 __MyCompanyName__. All rights reserved.
//

public class PNTTCPClient implements PNTClient {
    private static int LOCAL_PORT = 0;
    private static Logger logger = Logger.getLogger("pnt.client"); 
    
    final int RESEND_ATTEMPTS = 5;
    
    private EasySock primaryServer;
    private NATType typeOfNAT = null;
    
    String username;
    String applicationname;
    
    private boolean isOpen = false;
    
    
    public PNTTCPClient(InetAddress primaryServerAddr, int primaryServerPort, int localPort) {
        LOCAL_PORT = localPort;
        primaryServer = new EasySock(new InetSocketAddress(primaryServerAddr,primaryServerPort));
    }
    
    public void open() throws IOException {
        if (!isOpen){
            primaryServer.open();
            isOpen = true;
        }
    }
    
    public void close() throws IOException {
        primaryServer.close();
        isOpen = false;
    }
    
    /** NAT Discovery **/
    public NATType discoverNAT() throws IOException{
        try{
            if (primaryServer.sock == null || primaryServer.sock.isClosed() || !isOpen)
                throw new Error("Client has not been opened.");

            logger.fine("Discovering NAT type");

            PNTMessage helperResponse = getHelperEndpoints(1);

            InetSocketAddress addrFromPrimary = helperResponse.parseNATEndpoint();
            
            logger.fine("Primary server reports our address as " + addrFromPrimary.getAddress().getHostAddress() + 
                        " and port as " + addrFromPrimary.getPort());
            
            InetSocketAddress[] isa = helperResponse.parseHelperEndpoints();
            
            InetSocketAddress addrFromSecondary = null;

            for (InetSocketAddress i : isa ){
                // connect to helper endpoint
                EasySock e = new EasySock(i);
                e.open();


                addrFromSecondary = getNATEndpoint(e);
                logger.fine("Helper server reports our address as " + addrFromSecondary.getAddress().getHostAddress() + 
                            " and port as " + addrFromSecondary.getPort());
                e.close();
            }

            if (addrFromSecondary == null)
                logger.severe("Unable to parse address from secondary server. This should not be the case.");
            
            // Determine NAT type
            if(EasySock.privateEndpoint.equals(addrFromPrimary) && EasySock.privateEndpoint.equals(addrFromSecondary)){
                
                typeOfNAT = NATType.NO_NAT;
                primaryServer.setPublicEndpoint(addrFromPrimary);
                
            } else if (addrFromPrimary.equals(addrFromSecondary)) {
                
                typeOfNAT = NATType.CONE_NAT;
                primaryServer.setPublicEndpoint(addrFromPrimary);
                
            } else
                
                typeOfNAT = NATType.SYMMETRIC_NAT;
            
        } catch (PNTMessage.MalformedPacketException ex){
            
            logger.warning("Malformed packet caught outside of retry block. Terminating discovery procedure. Error: " + ex.getMessage());
            typeOfNAT = NATType.UNKNOWN_NAT;
            
        }
        
        logger.info("Type of NAT discovered as " + typeOfNAT.value + " (" + typeOfNAT.description + ").");
        
        return typeOfNAT;

    }

    
    private PNTMessage getHelperEndpoints(int numOfEndpoints){
        try {
            boolean resend;
            int errorCounter = 0;
            
            /***** Request Helper Endpoints *****/
            PNTMessage endpointReply = null;
            
            do {
                resend = false;
                
                // Request helper endpoints
                PNTMessage msg = PNTMessage.allocate_hep( numOfEndpoints, 
                                                          username, "("+primaryServer.sock.getLocalAddress()+","+primaryServer.sock.getLocalPort()+")");
                send(msg, primaryServer);

                // Get response
                try {
                    
                    endpointReply = PNTMessage.getMessage(primaryServer.sock);
                    logger.log(Level.FINE, "Message received from " + primaryServer.sock.getInetAddress().getHostAddress() + ":" + primaryServer.sock.getPort(), endpointReply);
                    
                    // Verify all's OK
                    if( endpointReply.getTransactionNumber() !=  msg.getTransactionNumber())
                        throw new PNTMessage.MalformedPacketException(
                                "Transaction numbers don't match.");
                    
                    if( endpointReply.response != PNTMessage.ResponseType.OK){
                        logger.log(Level.WARNING, "Not-OK response received", endpointReply);
                        return null;
                    }
                    
                } catch (PNTMessage.MalformedPacketException ex){
                    
                    System.err.println(ex);
                    if ( errorCounter++ < RESEND_ATTEMPTS){
                        logger.warning("Received malformed packet from primary server.");
                        resend = true;
                    } else {
                        logger.warning("Unable to connect to primary server.");
                        return null;
                    }
                    
                }
                
            } while (resend);
            
            return endpointReply;
            
        } catch (IOException ex) {
            logger.severe("Socket error occured." + ex.getMessage());
            return null;
        }
    }
    
    public boolean deregisterWithSupernode(int instance){
        try {
            PNTMessage deregisterMessage = PNTMessage.deregister("EN:" + username,
                                                                 applicationname, 
                                                                 Integer.toString(instance));
            send (deregisterMessage, primaryServer);

            /************* TODO: GET REPLY ******************/

            return true;
        
        } catch (IOException x){
            logger.warning("IOException caught when trying to deregister with supernode");
            return false;
        }
        
    }
    
    public boolean registerWithSupernode(boolean requireUserAuth, int instance){
        try{
            // TODO: refactor to allow for registering of multiple endpoints. Currently only permits 1.
            
            // Generate private endpoint string
            InetSocketAddress priv = primaryServer.getPrivateEndpoint();
            String privateEndpoints = "("+
                                     priv.getAddress().getHostAddress()+
                                     ","+
                                     priv.getPort()+
                                     ")";
            
            // Generate public endpoint string
            if(typeOfNAT == null) discoverNAT();
            
            String publicEndpoints = "";
            
            InetSocketAddress pub = primaryServer.getPublicEndpoint();
            String publicAddress = "("+
                                     pub.getAddress().getHostAddress()+
                                     ","+
                                     pub.getPort()+
                                     ")";
            
            // TODO: implement for CONE_NAT and SYMMETRIC_NAT
            switch(typeOfNAT){
                case NO_NAT:
                    publicEndpoints = "NoNAT:{" + publicAddress + "}";
                    break;
                case CONE_NAT:
                    throw new UnsupportedOperationException("Not yet implemented");
                    //publicEndpoints = "ConeNAT:{" + publicAddress + "," + XXXX + "}";
                    //break;
                case SYMMETRIC_NAT:
                    throw new UnsupportedOperationException("Not yet implemented");
                    //publicEndpoints = "SymmNAT:{" + XXXX + "}";
                    //break;
                default:
                    logger.severe("NAT discovery failed.");
                    break;
                    
            }
            
            // TODO: make selectable = Authorisation attribute, to attiribute, expires attribute
            PNTMessage registrationMessage = PNTMessage.register( requireUserAuth, 
                                                                  "Registered-Users", 
                                                                  "", 
                                                                  "EN:" + username, 
                                                                  "", 
                                                                  "EN:" + username, applicationname, 
                                                                  Integer.toString(instance),
                                                                  privateEndpoints,
                                                                  publicEndpoints,
                                                                  ""
                                                                  );
                                                                  
            send (registrationMessage, primaryServer);
            
            /************* TODO: GET REPLY ******************/
            
            
            return true;
        }catch(IOException x){
            logger.warning("IOException caught when trying to register with supernode");
            return false;
        }
        

    }
    
    /**
     * Sends a discover-nep message to the connected server.
     * 
     * @return the address returned by the server. null if unable to receive a message.
     */
    private InetSocketAddress getNATEndpoint(EasySock e){
        try {
            boolean resend;
            int errorCounter = 0;
            
            /***** Request Helper Endpoints *****/
            PNTMessage endpointReply = null;
            
            do {
                resend = false;
                
                // Request helper endpoints
                PNTMessage msg = PNTMessage.discover_nep( username, 
                                                          "("+e.sock.getLocalAddress()+","+e.sock.getLocalPort()+")" );
                send(msg, e);

                // Get response
                try {
                    
                    endpointReply = PNTMessage.getMessage(e.sock);
                    logger.log(Level.FINE, "Message received from " + e.sock.getInetAddress().getHostAddress() + ":" + e.sock.getPort(), endpointReply);
                    
                    // Verify all's OK
                    if( endpointReply.getTransactionNumber() !=  msg.getTransactionNumber())
                        throw new PNTMessage.MalformedPacketException(
                                "Transaction numbers don't match.");
                    
                    if( endpointReply.response != PNTMessage.ResponseType.OK){
                        logger.log(Level.WARNING, "Not-OK response received", endpointReply);
                        return null;
                    }
                    
                    return endpointReply.parseNATEndpoint();
                    
                } catch (PNTMessage.MalformedPacketException ex){
                    
                    System.err.println(ex);
                    if ( errorCounter++ < RESEND_ATTEMPTS){
                        logger.warning("Received malformed packet from primary server.");
                        resend = true;
                    } else {
                        logger.warning("Unable to connect to primary server.");
                        return null;
                    }
                    
                }
                
            } while (resend);
            
            return null;
            
        } catch (IOException ex) {
            logger.severe("Socket error occured." + ex.getMessage());
            return null;
        }
    }
    
    private void send(PNTMessage m, EasySock e) throws IOException{
        logger.log(Level.FINER, "Sending message to " + e.sock.getInetAddress().getHostAddress() + ":" +  e.sock.getPort(), m);
        e.out.write(m.toString());
        e.out.flush();
        logger.log(Level.FINE, "Message sent to " +  e.sock.getInetAddress().getHostAddress() + ":" +  e.sock.getPort(), m);
    }
    
    private static class EasySock {
        public BufferedReader in;
        public BufferedWriter out;
        Socket sock;
        
        SocketAddress server;
        
        static InetSocketAddress privateEndpoint; // The local endpoint from which messages are sent
        static InetSocketAddress publicEndpoint;  // The external endpoint mapping for the local endpoint
        
        boolean isOpen = false;

        /**
         * Changes primarySocket to connect to given address. If a change occurs,
         * the same source address/port will be used.
         * 
         * @param serverAddr
         * @param serverPrimaryPort
         */
        public EasySock(SocketAddress server){
            this.server = server;
        }
        
        public void open() throws IOException {
            
            if (!isOpen){
                
                if (privateEndpoint == null){ // Establish socket.
                    initPrimaryBinding();

                }

                sock = new Socket();
                sock.setReuseAddress(true);
                sock.bind(privateEndpoint);

                
                sock.connect(server);

                InputStream sin = sock.getInputStream();
                OutputStream sout = sock.getOutputStream();

                InputStreamReader inr = new InputStreamReader(sin);
                OutputStreamWriter outw = new OutputStreamWriter(sout);

                in = new BufferedReader(inr);
                out = new BufferedWriter(outw);

                isOpen = true; 
                logger.finer("Connected to " + 
                            sock.getInetAddress().getHostAddress() + 
                            ":" + 
                            sock.getPort() +
                            " with endpoint " + 
                            sock.getLocalAddress().getHostAddress() + 
                            ":" +
                            sock.getLocalPort());


            }
        }

        public void close() throws IOException {
            in.close();
            out.close();
            sock.close();
            sock = null;
            isOpen = false;
        }
        
        public InetSocketAddress getPrivateEndpoint() throws IOException{
            if (privateEndpoint == null) initPrimaryBinding();
            return privateEndpoint;
        }
        
        public InetSocketAddress getPublicEndpoint(){
            return publicEndpoint;
        }
        
        public void setPublicEndpoint(InetSocketAddress pe){
            publicEndpoint = pe;
        }
        
        private static void initPrimaryBinding() throws SocketException {
        // TODO: alter this to check from config file, if no file exists then
        // then prompt user and generate one.
        NetworkInterface i;
        
        i = NetworkInterface.getByName("eth0");
        if (i == null)
            i = NetworkInterface.getByName("en1");
        if (i == null)
            i = NetworkInterface.getByName("en0");

        Enumeration <InetAddress> addrs = i.getInetAddresses();
        InetAddress primaryAddress = null;
        for(InetAddress a : Collections.list(addrs)){
            // we only want IP4 addresses
            if (a.getClass().equals(java.net.Inet4Address.class))
                
                primaryAddress = a;
            
        }
        if(primaryAddress == null)
            privateEndpoint = new InetSocketAddress(LOCAL_PORT);
        else
            privateEndpoint = new InetSocketAddress(primaryAddress,LOCAL_PORT);
    }
        
    }
    
    public static enum NATType {
        NO_NAT("NoNAT", "No NAT takes place between the client and server."),
        CONE_NAT("ConeNAT", "NAT exists, but mapping is the same for all servers."),
        SYMMETRIC_NAT("SymmNAT", "NAT exists, and a new mapping is created for each server."),
        UNKNOWN_NAT("Unknown", "Something went wrong during the discovery process");
        
        String value;
        String description;
        
        NATType(String value, String description){
            this.value = value;
            this.description = description;
        }
    }
}
